library(tidyverse)
library(tidytext)
library(udpipe)6 Анализ частотностей
Основные этапы NLP включают в себя токенизацию, морфологический и синтаксический анализ, а также анализ семантики и прагматики. В этом уроке речь пойдет про первые три этапа. Мы научимся разбивать текст на токены (слова), определять морфологические характеристики слов и находить их начальные формы (леммы), а также анализировать структуру предложения с использованием синтаксических парсеров.
6.1 Данные
За основу для всех эти вычислений мы возьмем три философских трактата, написанных на английском языке. Это хронологически и тематически близкие тексты:
- “Опыт о человеческом разумении” Джона Локка (1690), первые две книги;
- “Трактат о принципах человеческого знания” Джорджа Беркли (1710);
- “Исследование о человеческом разумении” Дэвида Юма (1748).
Данные были извлечены с использованием библиотеки Gutengerg и приведены к опрятному виду. Скачать подготовленный таким образом корпус можно здесь.
load("../data/emp_corpus.Rdata")Делим корпус на слова уже известным вам способом.
corpus_words <- emp_corpus |>
unnest_tokens(word, text)
corpus_wordsБольшая часть слов, которые мы сейчас видим в корпусе – это шумовые слова, или стоп-слова, не несущие смысловой нагрузки.
corpus_words |>
count(word, sort = TRUE) |>
slice_head(n = 15) |>
ggplot(aes(reorder(word, n), n, fill = word)) +
geom_col(show.legend = F) +
coord_flip() 
Стоп-слова не обязательно удалять вручную, как мы делали ранее. Можно отобрать наиболее характерные слова или отсортировать по частям речи.
6.2 Наиболее характерные слова
Но абсолютная частотность – плохой показатель для текстов разной длины. Чтобы тексты было проще сравнивать, мы можем разделить показатели частотности на общее число токенов в тексте.
Cначала считаем частотность для всех токенов по авторам.
author_word_counts <- corpus_words |>
count(author, word, sort = T)
author_word_countsНаиболее частотные слова наименее подвержены влиянию тематики, поэтому их используют для стилометрического анализа. Если отобрать наиболее частотные после удаления стоп-слов, то мы получим достаточно адекватное отражение тематики документов. Если же мы необходимо найти наиболее характерные для документов токены, то применяется другая мера, которая называется tf-idf (term frequency - inverse document frequency).

Логарифм единицы равен нулю, поэтому если слово встречается во всех документах, его tf-idf равно нулю. Чем выше tf-idf, тем более характерно некое слово для документа. При этом относительная частотность тоже учитывается! Например, Беркли один раз упоминает “сахарные бобы”, а Локк – “миндаль”, но из-за редкой частотности tf-idf для подобных слов будет низкой.
Функция bind_tf_idf() принимает на входе тиббл с абсолютной частотностью для каждого слова.
author_word_tfidf <- author_word_counts |>
bind_tf_idf(word, author, n)
author_word_tfidfПосмотрим на слова с высокой tf-idf:
author_word_tfidf |>
arrange(-tf_idf)Теперь визуализируем.
author_word_tfidf |>
arrange(-tf_idf) |>
group_by(author) |>
top_n(15) |>
ungroup() |>
ggplot(aes(reorder_within(word, tf_idf, author), tf_idf, fill = author)) +
geom_col(show.legend = F) +
labs(x = NULL, y = "tf-idf") +
facet_wrap(~author, scales = "free") +
scale_x_reordered() +
coord_flip()
На такой диаграмме авторы совсем не похожи друг на друга, но будьте осторожны: все то, что их сближает (а это не только служебные части речи!), сюда просто не попало. Можно также заметить, что ряд характерных слов связаны не столько с тематикой, сколько со стилем: чтобы этого избежать, можно использовать лемматизацию или задать правило для замены вручную.
6.3 Лемматизация и POS-тэггинг
Лемматизация – приведение слов к начальной форме (лемме). Как правило, она проводится одновременно с частеречной разметкой (POS-tagging). Все это умеет делать UDPipe – обучаемый конвейер (trainable pipeline), для которого существует одноименный пакет в R.
Основным форматом файла для него является CoNLL-U. Файлы в таком формате хранятся в так называемых трибанках, то есть коллекциях уже размеченных текстов (название объясняется тем, что синтаксическая структура предложений представлена в них в виде древовидных графов). Файлы CoNLL-U используются для обучения нейросетей, но для большинства языков доступны хорошие предобученные модели, работать с которыми достаточно просто.
Пакет udpipe позволяет работать со множеством языков (всего 65), для многих из которых представлено несколько моделей, обученных на разных трибанках. Прежде всего нужно выбрать и загрузить модель (список). Описания моделей доступны на сайте https://universaldependencies.org/.
# скачиваем модель в рабочую директорию
# udpipe_download_model(language = "english-gum")
# загружаем модель
english_gum <- udpipe_load_model(file = "english-gum-ud-2.5-191206.udpipe")
# аннотируем (это займет несколько минут)
emp_ann <- udpipe_annotate(english_gum, emp_corpus$text, doc_id = emp_corpus$author)Результат возвращается в формате CoNLL-U; это широко применяемый формат представления результат морфологического и синтаксического анализа текстов.
Вот пример разбора предложения:
Cтроки слов содержат следующие поля:
ID: индекс слова, целое число, начиная с 1 для каждого нового предложения; может быть диапазоном токенов с несколькими словами.FORM: словоформа или знак препинания.LEMMA: Лемма или основа словоформы.UPOSTAG: тег части речи из универсального набора проекта UD, который создавался для того, чтобы аннотации разных языков были сравнимы между собой.XPOSTAG: тег части речи, который выбрали исследователи под конкретные нужды языкаFEATS: список морфологических характеристик.HEAD: идентификатор (номер) синтаксической вершины текущего токена. Если такой вершины нет, то ставят ноль (0).DEPREL: характер синтаксической зависимости.DEPS: Список вторичных зависимостей.MISC: любая другая аннотация.
Для работы данные удобнее трансформировать в прямоугольный формат.
emp_pos <- as_tibble(emp_ann) |>
select(-paragraph_id)
emp_pos 6.3.1 Поля UPOS и XPOS
Морфологическая аннотация, которую мы получили, дает возможность выбирать и группировать различные части речи. Например, существительные.
emp_pos |>
filter(upos == "NOUN") |>
select(doc_id, token, lemma, upos, xpos)Посчитать части речи можно так:
upos_counts <- emp_pos |>
count(doc_id, upos, sort = TRUE)
upos_countsОтносительные значения:
total_counts <- upos_counts |>
count(doc_id, wt = n, name = "sum_n")
pos_share <- upos_counts |>
left_join(total_counts) |>
mutate(share = round( (n/sum_n), 2))
pos_share |>
ggplot(aes(reorder(upos, share), share, fill = upos)) +
geom_col(show.legend = FALSE) +
coord_flip() +
theme_light() +
facet_wrap(~doc_id, scales = "free")
Отберем наиболее частотные имена и имена собственные.
nouns <- emp_pos |>
filter(upos %in% c("NOUN", "PROPN")) |>
count(doc_id, lemma, sort = TRUE)
nounsnouns |>
group_by(doc_id) |>
slice_head(n = 10) |>
ggplot(aes(reorder(lemma, n), n, fill = lemma)) +
geom_col(show.legend = FALSE) +
theme_light() +
coord_flip() +
facet_wrap(~ doc_id, scales = "free")
Сравните с результатом, который вы получили, считая TF-IDF.
В отличие от UPOS (Universal Part-Of-Speech), XPOS (Language-specific Part-Of-Speech) – это теги частей речи, используемые в национальных корпусах. Форматы тегов и их детализация могут значительно меняться от языка к языку.
6.3.2 Поля FEATS и DEP_REL
Допустим, нам нужны лишь определенные формы: например, прилагательные в превосходной степени.
superlatives <- emp_pos |>
filter(str_detect(feats, "Degree=Sup") & upos == "ADJ") |>
select(doc_id, token)
superlatives |>
count(doc_id, token, sort = TRUE)Аналогичным образом можно отбирать синтаксические признаки (DEP_REL) и их комбинации, а также визуализировать деревья зависимостей для отдельных предложений при помощи пакета {textplot}.
6.4 Совместная встречаемость слов
Функция cooccurence() из пакета udpipe позволяет выяснить, сколько раз некий термин встречается совместно с другим термином, например:
- слова встречаются в одном и том же документе/предложении/параграфе;
- слова следуют за другим словом;
- слова находятся по соседству с другим словом на расстоянии n слов.
Выясним, какие существительные чаще встречаются в одном предложении у Локка:
locke_subset <- subset(emp_pos, doc_id = "Locke")
locke_nouns <- locke_subset |>
filter(upos == "NOUN")
cooc <- cooccurrence(locke_nouns, term = "lemma",
group = c("doc_id", "sentence_id")) |>
as_tibble() |>
filter(cooc > 100)
coocЭтот результат легко визуализировать, используя пакет ggraph:
library(igraph)
library(ggraph)
wordnetwork <- graph_from_data_frame(cooc)
ggraph(wordnetwork, layout = "fr") +
geom_edge_arc(aes(width = cooc), alpha = 0.8, edge_colour = "grey90", show.legend=FALSE) +
geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
theme_void() +
labs(title = "Совместная встречаемость существительных в предложении")
Чтобы узнать, какие слова чаще стоят рядом, используем ту же функцию, но с другими аргументами:
cooc2 <- cooccurrence(locke_subset$lemma,
relevant = locke_subset$upos %in% c("NOUN", "ADJ"),
skipgram = 1) |>
as_tibble() |>
filter(cooc > 20)
cooc2wordnetwork <- graph_from_data_frame(cooc2)
ggraph(wordnetwork, layout = "fr") +
geom_edge_link(aes(width = cooc), edge_colour = "grey90", edge_alpha=0.8, show.legend = F) +
geom_node_label(aes(label = name), col = "#1f78b4") +
labs(title = "Слова, стоящие рядом в тексте") +
theme_void()Warning in geom_node_label(aes(label = name), col = "#1f78b4"): Ignoring
unknown parameters: `label.size`
